Skip to content

Implement env_file directive support#300

Merged
kindermax merged 8 commits into
masterfrom
codex/plan-envfile-directive-support
Mar 17, 2026
Merged

Implement env_file directive support#300
kindermax merged 8 commits into
masterfrom
codex/plan-envfile-directive-support

Conversation

@kindermax

@kindermax kindermax commented Mar 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add env_file parsing and runtime handling for both global and command scopes with interpolated filenames
  • document the new behavior and update the schema and changelog to describe the directive
  • include new dotenv fixtures/tests covering optional/missing files and precedence

Testing

  • Not run (not requested)

Summary by Sourcery

Add support for loading dotenv-style env files via a new env_file directive at both global and command scope, integrating them into environment resolution and precedence.

New Features:

  • Introduce an env_file directive in global config and commands to load dotenv-style files with optional/required semantics and filename interpolation.
  • Expose builtin runtime environment helpers for configs and commands to centralize LETS_* variable construction.

Enhancements:

  • Integrate env_file values into global and command environment resolution with defined precedence over env and caching of resolved environments.
  • Improve error messaging and logging to remove redundant prefixes and standardize lets error output formatting.

Build:

  • Add the godotenv dependency for parsing dotenv-style files.

Documentation:

  • Document the env_file directive usage, precedence rules, and examples in the configuration and environment docs, and update the changelog and JSON schema accordingly.

Tests:

  • Add unit tests for env_file parsing, loading behavior, and precedence, plus bats integration tests and fixtures covering optional/missing/invalid env files and OS-specific dotenv files.

@sourcery-ai

sourcery-ai Bot commented Mar 15, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds first-class support for dotenv-style env_file directives at both global and command scope, wiring them into config parsing, runtime environment resolution, precedence rules, and tests/docs, while refactoring builtin env handling and tightening some error messages/logging.

Sequence diagram for command environment resolution with env_file

sequenceDiagram
    actor User
    participant CLI as Main
    participant Exec as Executor
    participant Cfg as Config
    participant Cmd as Command
    participant EnvFilesGlobal as EnvFiles_global
    participant EnvFilesCmd as EnvFiles_command

    User->>CLI: invoke lets <command>
    CLI->>Exec: create Executor with Config

    Note over Exec,Cfg: Global env resolution (once per config)
    Exec->>Cfg: SetupEnv()
    activate Cfg
    Cfg->>Cfg: BuiltinEnv(shell)
    Cfg->>EnvFilesGlobal: Load(cfg, builtin+Env.Dump())
    EnvFilesGlobal-->>Cfg: map globalEnvFileEnv
    Cfg->>Cfg: merge Env.Dump() then globalEnvFileEnv into resolvedEnv
    Cfg-->>Exec: SetupEnv done
    deactivate Cfg

    Note over Exec,Cmd: Per-command env resolution
    Exec->>Cfg: CommandBuiltinEnv(Cmd, shell, workDir)
    Cfg-->>Exec: defaultEnv (builtin lets vars for command)

    Exec->>Cmd: GetEnv(*Cfg, defaultEnv)
    activate Cmd
    Cmd->>Cmd: clone builtinEnv
    Cmd->>Cfg: GetEnv()  (global resolvedEnv)
    Cfg-->>Cmd: globalEnv
    Cmd->>Cmd: merge globalEnv into baseEnv
    Cmd->>Cmd: Env.Execute(cfg, baseEnv)
    Cmd->>EnvFilesCmd: Load(cfg, baseEnv + Env.Dump())
    EnvFilesCmd-->>Cmd: map cmdEnvFileEnv
    Cmd->>Cmd: resolvedEnv = Env.Dump()
    Cmd->>Cmd: overlay cmdEnvFileEnv on resolvedEnv
    Cmd-->>Exec: cmdEnv (clone of resolvedEnv)
    deactivate Cmd

    Exec->>Exec: merge checksum vars, -E/--env, options
    Exec->>Exec: construct final process env
    Exec-->>User: run command with resolved environment
Loading

Class diagram for env_file support in config and command

classDiagram
    class Config {
      +string FilePath
      +string Before
      +string Init
      +Envs Env
      +EnvFiles EnvFiles
      +string Version
      +map~string,string~ resolvedEnv
      +bool isMixin
      +string DotLetsDir
      +string ChecksumsDir
      +map~string,*Command~ Commands
      +UnmarshalYAML(unmarshal)
      +mergeMixin(mixin *Config) error
      +readMixins(mixins []*Mixin) error
      +GetEnv() map~string,string~
      +SetupEnv() error
      +BuiltinEnv(shell string) map~string,string~
      +CommandBuiltinEnv(command *Command, shell string, workDir string) map~string,string~
    }

    class Command {
      +string Name
      +[]string Args
      +string WorkDir
      +string Description
      +Envs Env
      +EnvFiles EnvFiles
      +string Shell
      +string Docopts
      +bool SkipDocopts
      +map~string,string~ Options
      +map~string,string~ resolvedEnv
      +UnmarshalYAML(unmarshal) error
      +GetEnv(cfg Config, builtinEnv map~string,string~) (map~string,string~, error)
      +Clone() *Command
    }

    class EnvFile {
      +string Name
      +bool Required
      +UnmarshalYAML(unmarshal) error
    }

    class EnvFiles {
      +[]EnvFile Items
      +map~string,string~ loaded
      +bool ready
      +UnmarshalYAML(node *yaml.Node) error
      +Clone() *EnvFiles
      +Append(other *EnvFiles)
      +Load(cfg Config, envMap map~string,string~) (map~string,string~, error)
    }

    class Envs {
      +map~string,*Env~ Mapping
      +UnmarshalYAML(node *yaml.Node) error
      +Execute(cfg Config, baseEnv map~string,string~) error
      +Dump() map~string,string~
      +Clone() *Envs
      +Merge(other *Envs)
    }

    class Executor {
      -Config cfg
      +setupEnv(osCmd *exec.Cmd, command *config.Command, shell string) error
    }

    Config "1" o-- "many" Command : Commands
    Config "1" *-- Envs : Env
    Config "1" *-- EnvFiles : EnvFiles
    Command "1" *-- Envs : Env
    Command "1" *-- EnvFiles : EnvFiles
    EnvFiles "1" *-- "many" EnvFile : Items
    Executor ..> Config : uses
    Executor ..> Command : executes
    Command ..> Config : GetEnv
    EnvFiles ..> Config : Load
    Command ..> EnvFiles : EnvFiles
    Config ..> EnvFiles : EnvFiles
    Config ..> Envs : Env
    Command ..> Envs : Env
Loading

File-Level Changes

Change Details Files
Introduce EnvFile/EnvFiles types and loading logic to support dotenv-style env_file directives with interpolation, optional files, and precedence.
  • Define EnvFile and EnvFiles types with YAML unmarshalling for string/map/list forms, including optional-file shorthand using leading '-' and validation that names are non-empty.
  • Implement EnvFiles.Load to expand filenames against a provided env map, resolve relative to config.WorkDir, skip optional missing files, error on required missing files or parse errors, and cache loaded values.
  • Add helper functions for normalizing optional filenames and expanding with env plus process env.
internal/config/config/env_file.go
internal/config/config/env_file_test.go
Wire env_file into global Config parsing, mixin merging, and environment setup, including precedence over env and filename interpolation using builtin and global env.
  • Extend Config struct with EnvFiles and resolvedEnv cache, update YAML unmarshalling to read env_file and default to empty EnvFiles when omitted.
  • Update mixin merging so EnvFiles from mixins are appended to the base config.
  • Change SetupEnv to compute filename expansion env using BuiltinEnv + Env.Dump, load env_file values, cache merged env (env then env_file), and use GetEnv for command arg interpolation.
  • Add BuiltinEnv and CommandBuiltinEnv helpers to centralize builtin lets variables.
internal/config/config/config.go
internal/config/config/runtime_env.go
Wire env_file into per-command parsing and runtime env resolution with correct precedence and caching.
  • Extend Command struct with EnvFiles and resolvedEnv cache; update YAML unmarshalling to accept env_file and default EnvFiles when omitted.
  • Change Command.Clone to clone EnvFiles as well as Env.
  • Refactor Command.GetEnv to accept a builtinEnv map, build a base env from builtin + global config, execute command Env, then load command EnvFiles with filename interpolation based on merged env, and finally cache and return env merged with env_file (env_file winning on conflicts).
internal/config/config/command.go
Refactor executor environment setup to use central builtin env helpers and new Command.GetEnv signature.
  • Replace inline construction of LETS_* runtime variables in Executor.setupEnv with a call to Config.CommandBuiltinEnv.
  • Update calls to Command.GetEnv to pass in the builtin command env.
internal/executor/executor.go
internal/config/config/runtime_env.go
Document env_file behavior, precedence, and schema, and add changelog entry.
  • Extend config docs with sections for global env_file and command-level env_file including supported forms, rules, and examples.
  • Add env_file precedence and filename expansion rules to env docs.
  • Update changelog to announce env_file support and describe precedence and filename expansion behavior.
  • Update JSON schema to include env_file (diff omitted in snippet but file touched).
docs/docs/config.md
docs/docs/env.md
docs/docs/changelog.md
docs/static/schema.json
Add Bats and Go tests plus fixtures to cover env_file parsing, runtime behavior, errors, and precedence.
  • Add Go unit tests for EnvFiles parsing, Load behavior (override order, optional vs required, invalid syntax), Config.SetupEnv with env_file, and Command.GetEnv with env_file.
  • Add Bats integration tests and dotenv fixtures to verify global vs command env_file precedence, optional missing files, missing required files, and invalid syntax reporting with filenames.
internal/config/config/env_file_test.go
tests/env_file.bats
tests/env_file/lets.yaml
tests/env_file/lets.global-invalid.yaml
tests/env_file/lets.global-missing.yaml
tests/env_file/lets.command-missing.yaml
tests/env_file/.env.command.dev
tests/env_file/.env.darwin
tests/env_file/.env.global
tests/env_file/.env.invalid
tests/env_file/.env.linux
Adjust error and logging messages to be less prefixed and more consistent with new behavior.
  • Simplify several config parsing error messages by removing the 'lets:' prefix so they read more generically (env, deps, etc.).
  • Change main error logging to add a single 'lets:' prefix via log.Errorf, and tweak FindConfig debug logging message formatting.
internal/config/config/env.go
internal/config/config/deps.go
cmd/lets/main.go
internal/config/find.go
Add dependency on godotenv for parsing dotenv-style files.
  • Add github.com/joho/godotenv to go.mod and go.sum for dotenv parsing.
go.mod
go.sum

Possibly linked issues

  • #(not specified): The PR implements dotenv-style env_file support, precedence, and global keyword behavior exactly as requested in the issue.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 6 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="internal/config/config/command.go" line_range="138-147" />
<code_context>

-func (c *Command) GetEnv(cfg Config) (map[string]string, error) {
-	if err := c.Env.Execute(cfg, cfg.GetEnv()); err != nil {
+func (c *Command) GetEnv(cfg Config, builtinEnv map[string]string) (map[string]string, error) {
+	if c.resolvedEnv != nil {
+		return cloneMap(c.resolvedEnv), nil
+	}
+
+	baseEnv := cloneMap(builtinEnv)
+	if baseEnv == nil {
+		baseEnv = make(map[string]string)
+	}
+	for key, value := range cfg.GetEnv() {
+		baseEnv[key] = value
+	}
+
+	if err := c.Env.Execute(cfg, baseEnv); err != nil {
 		return nil, err
 	}

-	return c.Env.Dump(), nil
+	filenameEnv := cloneMap(baseEnv)
+	for key, value := range c.Env.Dump() {
+		filenameEnv[key] = value
+	}
+
+	envFileEnv, err := c.EnvFiles.Load(cfg, filenameEnv)
+	if err != nil {
+		return nil, fmt.Errorf("failed to resolve env_file for command '%s': %w", c.Name, err)
+	}
+
+	c.resolvedEnv = c.Env.Dump()
+	for key, value := range envFileEnv {
+		c.resolvedEnv[key] = value
+	}
+
+	return cloneMap(c.resolvedEnv), nil
 }

</code_context>
<issue_to_address>
**issue (bug_risk):** Command env caching ignores builtinEnv, so repeated runs with different args/dirs/shell reuse incorrect env

`c.resolvedEnv` is cached per `Command` instance, but the computed env depends on `builtinEnv` (`LETS_COMMAND_ARGS`, `LETS_COMMAND_WORK_DIR`, `LETS_SHELL`, etc.). Re-invoking the same `Command` with different args/dirs/shell will still return a clone of the first computed env, ignoring later `builtinEnv` values.

To avoid stale envs across invocations, either remove caching at the `Command` level or incorporate the relevant `builtinEnv` values into the cache key / `resolvedEnv` so each distinct invocation gets its own env.
</issue_to_address>

### Comment 2
<location path="internal/config/config/env_file.go" line_range="107-116" />
<code_context>
+func (e *EnvFiles) Load(cfg Config, envMap map[string]string) (map[string]string, error) {
</code_context>
<issue_to_address>
**issue (bug_risk):** EnvFiles.Load caches results without considering cfg or envMap, which can lead to incorrect reuse

The cache in `EnvFiles.Load` is keyed only by the `EnvFiles` instance, but the result also depends on `cfg.WorkDir` and `envMap` (via `expandWithEnv`). Reusing the same `EnvFiles` with different configs/envMaps will incorrectly return the first `loaded` result.

Either remove this caching, or scope it to a specific `(cfg, envMap)` context (or move caching to a higher layer where those inputs are stable).
</issue_to_address>

### Comment 3
<location path="internal/config/config/env_file.go" line_range="26-35" />
<code_context>
+	ready  bool
+}
+
+func (e *EnvFile) UnmarshalYAML(unmarshal func(interface{}) error) error {
+	var filename string
+	if err := unmarshal(&filename); err == nil {
+		e.Name = normalizeEnvFilename(filename)
+		e.Required = !isOptionalEnvFilename(filename)
+		if e.Name == "" {
+			return errors.New("env_file name can not be empty")
+		}
+
+		return nil
+	}
+
+	var raw struct {
+		Name     string
+		Required *bool
+	}
+	if err := unmarshal(&raw); err != nil {
+		return err
+	}
+
+	if raw.Name == "" {
+		return errors.New("env_file name can not be empty")
+	}
+
+	e.Name = raw.Name
+	e.Required = true
+	if raw.Required != nil {
+		e.Required = *raw.Required
+	}
+
+	return nil
+}
+
</code_context>
<issue_to_address>
**question:** Scalar and map env_file forms handle optional filenames differently; consider normalizing behavior

In the scalar form, `normalizeEnvFilename`/`isOptionalEnvFilename` treat a leading `-` as “optional” and strip it from the stored name. In the map form, `raw.Name` is used verbatim and `Required` defaults to true, so `name: -file.env` stays `-file.env` and is not treated as optional. If the map form is meant to mirror the scalar semantics, consider applying the same normalization/optionality logic to `raw.Name`.
</issue_to_address>

### Comment 4
<location path="internal/config/config/env_file_test.go" line_range="36" />
<code_context>
+	}
+}
+
+func TestParseEnvFiles(t *testing.T) {
+	t.Run("parses mixed env_file forms", func(t *testing.T) {
+		text := dedent.Dedent(`
</code_context>
<issue_to_address>
**suggestion (testing):** Add tests for scalar and map `env_file` forms and invalid top-level kinds

`TestParseEnvFiles` currently only exercises the sequence form and the error for a map item missing `name`. To improve coverage, please also add:

- A scalar case (`env_file: .env`) to confirm single-string values parse correctly.
- A valid map case (e.g. `env_file: { name: .env.prod, required: false }`) to verify `required` is respected.
- An invalid top-level kind (e.g. integer or boolean) to assert the `env_file must be a string, map, or sequence` error from `EnvFiles.UnmarshalYAML` is triggered.

Suggested implementation:

```golang
func TestParseEnvFiles(t *testing.T) {
	t.Run("parses scalar env_file", func(t *testing.T) {
		text := dedent.Dedent(`
		env_file: .env
		`)

		var raw struct {
			EnvFiles *EnvFiles `yaml:"env_file"`
		}

		if err := yaml.Unmarshal([]byte(text), &raw); err != nil {
			t.Fatalf("unexpected error unmarshalling scalar env_file: %v", err)
		}

		if raw.EnvFiles == nil {
			t.Fatalf("expected EnvFiles to be populated for scalar env_file")
		}

		if len(*raw.EnvFiles) != 1 {
			t.Fatalf("expected 1 env_file entry, got %d", len(*raw.EnvFiles))
		}

		// Adjust field names if they differ in EnvFile
		if (*raw.EnvFiles)[0].Name != ".env" {
			t.Fatalf("expected env_file name '.env', got %q", (*raw.EnvFiles)[0].Name)
		}
	})

	t.Run("parses map env_file", func(t *testing.T) {
		text := dedent.Dedent(`
		env_file:
		  name: .env.prod
		  required: false
		`)

		var raw struct {
			EnvFiles *EnvFiles `yaml:"env_file"`
		}

		if err := yaml.Unmarshal([]byte(text), &raw); err != nil {
			t.Fatalf("unexpected error unmarshalling map env_file: %v", err)
		}

		if raw.EnvFiles == nil {
			t.Fatalf("expected EnvFiles to be populated for map env_file")
		}

		if len(*raw.EnvFiles) != 1 {
			t.Fatalf("expected 1 env_file entry, got %d", len(*raw.EnvFiles))
		}

		ef := (*raw.EnvFiles)[0]

		// Adjust field names if they differ in EnvFile
		if ef.Name != ".env.prod" {
			t.Fatalf("expected env_file name '.env.prod', got %q", ef.Name)
		}

		if ef.Required {
			t.Fatalf("expected env_file required=false to be respected")
		}
	})

	t.Run("errors on invalid top-level kind", func(t *testing.T) {
		text := dedent.Dedent(`
		env_file: 123
		`)

		var raw struct {
			EnvFiles *EnvFiles `yaml:"env_file"`
		}

		err := yaml.Unmarshal([]byte(text), &raw)
		if err == nil {
			t.Fatalf("expected error unmarshalling invalid top-level env_file kind, got nil")
		}

		if !strings.Contains(err.Error(), "env_file must be a string, map, or sequence") {
			t.Fatalf("expected env_file kind error, got: %v", err)
		}
	})

	t.Run("parses mixed env_file forms", func(t *testing.T) {
		text := dedent.Dedent(`
		env_file:
		  - .env
		  - -.env.local
		  - name: .env.prod
		    required: false
		`)

		var raw struct {
			EnvFiles *EnvFiles `yaml:"env_file"`
		}

```

1. Ensure `yaml` and `strings` are imported in this test file (they likely already are for `yaml`, but `strings` may need to be added):
   - `gopkg.in/yaml.v3` (or whatever YAML package the file already uses).
   - `"strings"` for the error substring check.
2. The code above assumes:
   - `EnvFiles` is a slice-like type (`[]EnvFile`) so that `len(*raw.EnvFiles)` and index access are valid.
   - The element type has fields `Name string` and `Required bool`.
   If the actual type or field names differ (e.g. `Path` instead of `Name`, or `Optional` instead of `Required`), update the field accesses and assertions accordingly.
3. Keep the remainder of the existing `"parses mixed env_file forms"` subtest body (assertions, error checks, etc.) after the shown snippet so current coverage is preserved.
</issue_to_address>

### Comment 5
<location path="internal/config/config/env_file_test.go" line_range="90" />
<code_context>
+	})
+}
+
+func TestEnvFilesLoad(t *testing.T) {
+	workDir := t.TempDir()
+	writeFixtureFile(t, workDir, ".env.first", "VALUE=first\n")
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test to cover filename interpolation precedence between provided env map and process environment

Current `TestEnvFilesLoad` cases only pass `nil` for the env map, so they don’t cover precedence between the explicit env map and `os.Environ()` during filename interpolation. Please add a subtest that:

- Sets `os.Setenv("TARGET", "from-os")`.
- Calls `Load` with an env map containing `TARGET=from-map` and a pattern like `.env.${TARGET}`.
- Asserts the selected filename resolves using `from-map`, not `from-os`.

This will lock in the intended precedence behavior for filename expansion.

Suggested implementation:

```golang
	cfg := Config{WorkDir: workDir}

	t.Run("env map overrides process env for filename interpolation", func(t *testing.T) {
		t.Setenv("TARGET", "from-os")

		envFiles := &EnvFiles{
			Items: []EnvFile{
				{Name: ".env.${TARGET}", Required: true},
			},
		}

		env := map[string]string{
			"TARGET": "from-map",
		}

		ctx := context.Background()
		values, err := cfg.LoadEnvFiles(ctx, envFiles, env)
		if err != nil {
			t.Fatalf("LoadEnvFiles failed: %v", err)
		}

		if got, want := values["VALUE"], "first"; got != want {
			t.Fatalf("unexpected VALUE: got %q, want %q", got, want)
		}
	})

	t.Run("later files override earlier files", func(t *testing.T) {

```

1. Ensure `context` is imported at the top of `env_file_test.go`:
   - If not present, add `import "context"` (or include it in the existing import block).
2. If the function under test has a different name or signature than `cfg.LoadEnvFiles(ctx, envFiles, env)`, adjust the call accordingly so that:
   - The first argument is a `context.Context` (or whatever your existing tests use).
   - The second argument is the `EnvFiles` collection.
   - The third argument is the explicit env map used for interpolation.
3. The assertion assumes `.env.first` contains `VALUE=first` and that `.env.${TARGET}` is resolved using `TARGET` from the provided env map; if your API returns values in a different structure, adapt the indexing (`values["VALUE"]`) to your actual return type.
</issue_to_address>

### Comment 6
<location path="internal/config/config/env_file_test.go" line_range="165" />
<code_context>
+	})
+}
+
+func TestConfigSetupEnvWithEnvFile(t *testing.T) {
+	workDir := t.TempDir()
+	writeFixtureFile(t, workDir, ".env.global", "FROM_FILE=global-file\nOVERRIDE=from-file\n")
</code_context>
<issue_to_address>
**suggestion (testing):** Add an assertion that subsequent GetEnv calls are stable and do not mutate shared state

Since config env is cached, consider also asserting that a second `cfg.GetEnv()` call returns an equal but distinct map (mutating the first result does not affect the second). This clarifies the contract that callers get an isolated copy while still benefiting from caching.

Suggested implementation:

```golang
package config

import (
	"testing"

	"github.com/lithammer/dedent"
)

// TestGetEnvReturnsIsolatedCopies verifies that cfg.GetEnv() is stable
// and returns an equal but distinct map on subsequent calls, so that
// mutating the first result does not affect the second or subsequent calls.
func TestGetEnvReturnsIsolatedCopies(t *testing.T) {
	workDir := t.TempDir()

	cfg := loadConfigFixture(t, workDir, dedent.Dedent(`
	shell: bash
	env:
	  FOO: foo
	  BAR: bar
	`))

	// First call to GetEnv
	env1 := cfg.GetEnv()
	if env1["FOO"] != "foo" || env1["BAR"] != "bar" {
		t.Fatalf("unexpected env1 contents: %#v", env1)
	}

	// Second call should return the same logical values
	env2 := cfg.GetEnv()
	if env2["FOO"] != "foo" || env2["BAR"] != "bar" {
		t.Fatalf("unexpected env2 contents: %#v", env2)
	}

	// The maps should be logically equal
	if len(env1) != len(env2) {
		t.Fatalf("env1 and env2 length differ: %d vs %d", len(env1), len(env2))
	}
	for k, v1 := range env1 {
		if v2, ok := env2[k]; !ok || v1 != v2 {
			t.Fatalf("env mismatch for key %q: env1=%q env2=%q (present=%v)", k, v1, v2, ok)
		}
	}

	// Mutate the first map and ensure it does not affect the second
	env1["NEW_FROM_ENV1"] = "value"
	delete(env1, "FOO")

	if _, ok := env2["NEW_FROM_ENV1"]; ok {
		t.Fatalf("env2 unexpectedly saw mutation from env1: %#v", env2)
	}
	if env2["FOO"] != "foo" {
		t.Fatalf("env2[\"FOO\"] changed after mutating env1: %#v", env2)
	}

	// A third call should also be unaffected by mutations to env1/env2
	env3 := cfg.GetEnv()
	if env3["FOO"] != "foo" || env3["BAR"] != "bar" {
		t.Fatalf("env3 contents unexpectedly changed: %#v", env3)
	}
	if _, ok := env3["NEW_FROM_ENV1"]; ok {
		t.Fatalf("env3 unexpectedly saw mutation from env1: %#v", env3)
	}
}

```

1. Ensure the package name `package config` at the top of the new file matches the existing package declaration in `internal/config/config/env_file_test.go` and other tests in this directory; adjust it if the package name is different.
2. This test assumes that `loadConfigFixture` and `writeFixtureFile` (if needed elsewhere) are helper functions already defined in the same package, as in the existing tests; if they are in a different package or have different names, update the calls accordingly.
3. If `github.com/lithammer/dedent` is imported in a common `_test.go` file under the same package, you can remove the explicit import here and rely on that, or adjust the import path if your project uses a local dedent helper instead.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread internal/config/config/command.go
Comment thread internal/config/config/env_file.go
Comment thread internal/config/config/env_file.go
Comment thread internal/config/config/env_file_test.go
Comment thread internal/config/config/env_file_test.go
Comment thread internal/config/config/env_file_test.go
@greptile-apps

greptile-apps Bot commented Mar 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR implements the env_file directive for lets, adding support for loading dotenv-style files at both the global config scope and per-command scope, with variable interpolation in filenames, optional/required semantics (via - prefix or required: false), and correct precedence layering over the existing env directive. The implementation is well-structured, introduces a new godotenv dependency, refactors builtin env construction into runtime_env.go, ships unit and BATS integration tests, and updates documentation and JSON schema.

Key points from the review:

  • A .env file containing CUSTOM_NAME=xxx was accidentally committed to the repository root — this is a development artifact that should be removed or added to .gitignore
  • expandWithEnv silently falls back to os.Getenv() for variables not found in the configured env maps; this undocumented behavior means shell environment variables can influence which env files are loaded
  • EnvFiles.Load() maintains its own loaded/ready cache that is redundant (callers already cache via resolvedEnv) and creates a fragile implicit ordering contract with Append() — future callers could silently lose appended entries if Load() has already been called
  • The test fixture tests/env_file/lets.yaml marks .env.${LETS_OS} as required, but only provides linux and darwin variants, so the test suite would fail on any other OS

Confidence Score: 3/5

  • The core feature logic is sound, but an accidentally committed .env artifact at the repository root should be cleaned up before merging.
  • The feature is well-implemented with good test coverage and clear documentation. The main blocker is the accidental .env commit at the root (which pollutes all contributor environments). The redundant inner cache in EnvFiles and the undocumented os.Getenv fallback are not bugs today but add design fragility and unexpected behavior that should be addressed.
  • .env (must be removed/gitignored), internal/config/config/env_file.go (caching design and expandWithEnv fallback)

Important Files Changed

Filename Overview
internal/config/config/env_file.go New file implementing EnvFile/EnvFiles types with YAML unmarshalling, cloning, appending, and a Load() method that resolves filenames via env interpolation and reads dotenv files via godotenv. Key concerns: the inner loaded/ready cache is redundant (callers already cache), creates a fragile ordering constraint with Append(), and expandWithEnv silently falls back to os.Getenv() for undocumented process-env interpolation in filenames.
internal/config/config/config.go Adds EnvFiles field and resolvedEnv cache to Config; SetupEnv() now loads global env_file after resolving global env, and stores the merged result in resolvedEnv. GetEnv() returns from cache when available. Mixin merging correctly appends env_file entries before Load() is called. Logic is sound.
internal/config/config/command.go Adds EnvFiles and resolvedEnv to Command; GetEnv() now accepts builtinEnv, builds a merged base env (builtin + global), executes command env with that context, expands filenames using the full merged set, and caches the result. Clone correctly propagates EnvFiles without carrying over the load cache.
.env Development artifact (CUSTOM_NAME=xxx) accidentally committed to the repository root. Not present in .gitignore; will be visible to all contributors and could affect env resolution for any lets.yaml at the root that loads .env.
internal/config/config/runtime_env.go New file extracting BuiltinEnv and CommandBuiltinEnv helpers from executor.go into the config package, enabling env_file filename expansion to use the same builtin variables (LETS_OS, LETS_CONFIG_DIR, etc.). Clean refactor with no issues.
internal/config/config/env_file_test.go Comprehensive unit tests covering YAML parsing of all three env_file forms, load precedence (later files override earlier), optional missing files, required missing files, invalid syntax, and end-to-end integration with SetupEnv() and Command.GetEnv(). Good coverage.
internal/executor/executor.go Updated to use cfg.CommandBuiltinEnv() helper (replacing inline map) and passes defaultEnv to command.GetEnv() for filename expansion context. Minimal, clean change.
tests/env_file/lets.yaml Main BATS test fixture covering global env_file with variable interpolation and OS-specific files, command-scoped env_file, and optional missing files. The .env.${LETS_OS} entry is required but only linux and darwin fixtures exist — the test suite would fail on any other platform.
tests/env_file.bats New BATS integration tests for all env_file scenarios: global/command loading with precedence, optional missing file, required missing global/command file errors, and invalid file format error. Good coverage across success and failure paths.

Sequence Diagram

sequenceDiagram
    participant M as main.go
    participant C as Config
    participant Cmd as Command
    participant EF as EnvFiles
    participant GD as godotenv

    M->>C: SetupEnv()
    C->>C: Env.Execute() — resolve global env sh expressions
    C->>C: Build filenameEnv (BuiltinEnv + global Env.Dump())
    C->>EF: Load(cfg, filenameEnv)
    EF->>EF: expandWithEnv(item.Name, filenameEnv) per file
    EF->>GD: Read(resolvedFilePath)
    GD-->>EF: map[string]string
    EF-->>C: envFileEnv
    C->>C: resolvedEnv = Env.Dump() + envFileEnv

    M->>M: executor.setupEnv(osCmd, command, shell)
    M->>C: CommandBuiltinEnv(command, shell, workDir)
    C-->>M: defaultEnv (LETS_* vars)
    M->>Cmd: GetEnv(cfg, defaultEnv)
    Cmd->>Cmd: baseEnv = defaultEnv + cfg.GetEnv()
    Cmd->>Cmd: Env.Execute(cfg, baseEnv) — resolve command env sh
    Cmd->>Cmd: filenameEnv = baseEnv + Env.Dump()
    Cmd->>EF: Load(cfg, filenameEnv)
    EF->>GD: Read(resolvedFilePath)
    GD-->>EF: map[string]string
    EF-->>Cmd: envFileEnv
    Cmd->>Cmd: resolvedEnv = Env.Dump() + envFileEnv
    Cmd-->>M: cmdEnv

    M->>M: Build final env list (os.Environ + defaultEnv + cfg.GetEnv() + cmdEnv + options + checksums)
    M->>M: osCmd.Env = envList
Loading

Last reviewed commit: 52c48c8

Comment thread .env Outdated
Comment thread internal/config/config/env_file.go
Comment thread internal/config/config/env_file.go
Comment thread tests/env_file/lets.yaml
@kindermax kindermax force-pushed the codex/plan-envfile-directive-support branch from 52c48c8 to cd663b4 Compare March 15, 2026 19:45
@kindermax kindermax merged commit 549f361 into master Mar 17, 2026
5 checks passed
@kindermax kindermax deleted the codex/plan-envfile-directive-support branch March 17, 2026 08:20
@kindermax kindermax mentioned this pull request Mar 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant